// Copyright 2024 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package botapi

import (
	"context"
	"strconv"
	"strings"

	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"

	"go.chromium.org/luci/common/errors"
	"go.chromium.org/luci/common/logging"

	"go.chromium.org/luci/swarming/server/botinfo"
	"go.chromium.org/luci/swarming/server/botsrv"
	"go.chromium.org/luci/swarming/server/model"
	"go.chromium.org/luci/swarming/server/tasks"
)

// TaskErrorRequest is sent by the bot.
type TaskErrorRequest struct {
	// Session is a serialized Swarming Bot Session proto.
	Session []byte `json:"session"`

	// TaskID is the TaskResultSummary packed key of the task to report error.
	//
	// Required.
	TaskID string `json:"task_id"`

	// RequestUUID is used to skip reporting duplicate events on retries.
	//
	// Generated by the client (usually an UUID4 string). Optional.
	RequestUUID string `json:"request_uuid,omitempty"`

	// Message is an optional arbitrary text message associated with this task
	// error.
	//
	// Will show up in the UI and when listing bot events in the API. Not
	// interpreted by the server in any way.
	Message string `json:"message,omitempty"`

	// ClientError is the client errors.
	ClientError *ClientError `json:"client_error,omitempty"`
}

// ClientError is the client errors.
type ClientError struct {
	// MissingCAS is the missing CAS digests in CLIENT_ERROR state.
	MissingCAS []CASReference `json:"missing_cas,omitempty"`
	// MissingCIPD is the missing CIPD packages in CLIENT_ERROR state.
	MissingCIPD []model.CIPDPackage `json:"missing_cipd,omitempty"`
}

// CASReference described where to fetch input files from.
// TODO(b/355013586): change bot code to report CASReference in the same format
// as model.CASReference.
type CASReference struct {
	// Instance is a full name of RBE-CAS instance.
	Instance string `json:"instance"`
	// Digest identifies the root tree to fetch in the format "<hash>/<size_bytes>"
	Digest string `json:"digest"`
}

func (r *TaskErrorRequest) ExtractSession() []byte { return r.Session }
func (r *TaskErrorRequest) ExtractDebugRequest() any {
	return &TaskErrorRequest{
		TaskID:      r.TaskID,
		RequestUUID: r.RequestUUID,
		Message:     r.Message,
		ClientError: r.ClientError,
	}
}

// TaskErrorResponse is returned by the server.
type TaskErrorResponse struct {
	// Empty for now.
}

// TaskError implements the handler that collects internal task errors.
//
// Uses optional "TaskID" route parameter with the task being worked on by
// the bot.
func (srv *BotAPIServer) TaskError(ctx context.Context, body *TaskErrorRequest, r *botsrv.Request) (botsrv.Response, error) {
	tr, err := validateTaskID(ctx, body.TaskID, r.CurrentTaskID)
	if errors.Is(err, wrongTaskIDErr) {
		logging.Warningf(ctx, "The bot is not associated with this task on the server")
		return &TaskErrorResponse{}, nil
	}
	if err != nil {
		return nil, err
	}

	var clientError *tasks.ClientError
	// Bot sends empty lists as missing_cas and missing_cipd when they are
	// actually None (http://shortn/_SMQs8Oh1KX).
	// TODO(b/355013586): Change bot code to actually omit client error if both
	// missing_cas and missing_cipd are none.
	if body.ClientError != nil && (len(body.ClientError.MissingCAS) > 0 || len(body.ClientError.MissingCIPD) > 0) {
		clientError = &tasks.ClientError{
			MissingCIPD: body.ClientError.MissingCIPD,
		}
		if len(body.ClientError.MissingCAS) > 0 {
			clientError.MissingCAS = make([]model.CASReference, len(body.ClientError.MissingCAS))
			for i, cas := range body.ClientError.MissingCAS {
				clientError.MissingCAS[i] = model.CASReference{
					CASInstance: cas.Instance,
				}
				digestParts := strings.Split(cas.Digest, "/")
				if len(digestParts) != 2 {
					return nil, status.Errorf(codes.InvalidArgument, "invalid CAS digest %q", cas.Digest)
				}
				size, err := strconv.ParseInt(digestParts[1], 10, 64)
				if err != nil {
					return nil, status.Errorf(codes.InvalidArgument, "invalid CAS digest %q", cas.Digest)
				}
				clientError.MissingCAS[i].Digest = model.CASDigest{
					Hash:      digestParts[0],
					SizeBytes: size,
				}
			}
		}
	}

	taskCompletion := &tasks.CompleteOp{
		Request:     tr,
		BotID:       r.Session.BotId,
		TaskError:   true,
		ClientError: clientError,
	}

	update := &botinfo.Update{
		BotID:         r.Session.BotId,
		EventDedupKey: body.RequestUUID,
		EventType:     model.BotEventTaskError,
		EventMessage:  body.Message,
		TasksManager:  srv.tasksManager,
		Prepare: func(ctx context.Context, _ *model.BotInfo) (*botinfo.PrepareOutcome, error) {
			_, err := srv.tasksManager.CompleteTxn(ctx, taskCompletion)
			if err != nil {
				return nil, err
			}
			return &botinfo.PrepareOutcome{Proceed: true}, nil
		},
		CallInfo: botCallInfo(ctx, &botinfo.CallInfo{
			SessionID: r.Session.SessionId,
		}),
	}

	if err := srv.submitUpdate(ctx, update); err != nil {
		if status.Code(err) != codes.Unknown {
			return nil, err
		}
		return nil, status.Errorf(codes.Internal, "failed to record the task error: %s", err)
	}

	return &TaskErrorResponse{}, nil
}
